game_manager_lib\commands\metadata/
refresh.rs

1//! Módulo de atualização automática em background (Preços e Reviews).
2//!
3//! Executa sem travar a UI e falha silenciosamente em caso de erro.
4
5use crate::constants::{BACKGROUND_TASK_INTERVAL_SECS, STARTUP_DELAY_SECS};
6use crate::database::AppState;
7use crate::errors::AppError;
8use crate::services::{cache, itad, steam};
9use rusqlite::params;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::time::Duration;
12use tauri::{AppHandle, Emitter, Manager, State};
13use tokio::time::sleep;
14use tracing::{error, info, warn};
15
16// Flag global para evitar execuções duplicadas
17static BACKGROUND_REFRESH_RUNNING: AtomicBool = AtomicBool::new(false);
18
19/// Comando disparado ao iniciar o app.
20/// Roda numa thread separada (spawn) para não bloquear a inicialização.
21/// Protegido contra execução duplicada (React Strict Mode chama useEffect 2x).
22#[tauri::command]
23pub async fn check_and_refresh_background(app: AppHandle) -> Result<(), AppError> {
24    // Verifica se já está rodando (previne duplicação)
25    if BACKGROUND_REFRESH_RUNNING.swap(true, Ordering::SeqCst) {
26        // Já existe uma instância rodando, ignora esta chamada
27        return Ok(());
28    }
29
30    // Clone do app_handle para usar no spawn
31    let app_clone = app.clone();
32
33    // SPAWN: Isso garante que o Frontend continua fluido imediatamente
34    tauri::async_runtime::spawn(async move {
35        // Pequeno delay inicial para não competir com o boot do banco de dados
36        sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
37
38        let state: State<AppState> = app_clone.state();
39
40        // 1. Atualizar Reviews da Steam (Se cache > 7 dias)
41        if let Err(e) = refresh_steam_reviews_background(&state).await {
42            warn!("Falha ao atualizar reviews: {}", e);
43        }
44
45        // 2. Atualizar Preços da Wishlist (Se cache > 3 dias)
46        sleep(Duration::from_secs(BACKGROUND_TASK_INTERVAL_SECS)).await;
47
48        if let Err(e) = refresh_wishlist_prices_background(&app_clone, &state).await {
49            warn!("Falha ao atualizar preços: {}", e);
50        }
51
52        // Opcional: Avisar frontend que terminou (para debug)
53        let _ = app_clone.emit("background_refresh_complete", ());
54
55        // Reseta a flag para permitir futuras execuções (ex: se usuário recarregar o app)
56        BACKGROUND_REFRESH_RUNNING.store(false, Ordering::SeqCst);
57    });
58
59    Ok(())
60}
61
62/// Atualiza reviews apenas se o cache estiver expirado
63async fn refresh_steam_reviews_background(state: &State<'_, AppState>) -> Result<(), String> {
64    // A. Ler IDs da Steam do banco (Leitura rápida)
65    let steam_games: Vec<(u32, String)> = {
66        let conn = state.library_db.lock().map_err(|_| "Falha DB Lock")?;
67
68        conn.prepare("SELECT platform_id, title FROM games WHERE platform = 'Steam'")
69            .and_then(|mut stmt| {
70                stmt.query_map([], |row| {
71                    let id_str: String = row.get(0)?;
72                    let title: String = row.get(1)?;
73                    Ok((id_str.parse::<u32>().unwrap_or(0), title))
74                })
75                .and_then(|mapped| mapped.collect::<Result<Vec<_>, _>>())
76            })
77            .map_err(|e| e.to_string())?
78            .into_iter()
79            .filter(|(id, _)| *id > 0)
80            .collect()
81    };
82
83    if steam_games.is_empty() {
84        return Ok(());
85    }
86
87    let mut updated_count = 0;
88
89    // B. Iterar jogos
90    for (app_id, _title) in steam_games {
91        let should_update = {
92            match state.metadata_db.lock() {
93                Ok(cache_conn) => {
94                    let cache_key = format!("reviews_{}", app_id);
95                    // Verifica se o cache expirou
96                    cache::get_cached_api_data(&cache_conn, "steam", &cache_key).is_none()
97                }
98                Err(_) => false, // Se erro ao acessar cache, pula atualização
99            }
100        };
101
102        if should_update {
103            let app_id_str = app_id.to_string();
104            // C. Busca na API (Só se expirou)
105            match steam::get_app_reviews(&app_id_str).await {
106                Ok(Some(summary)) => {
107                    // D. Sucesso? Atualiza Library DB e Metadata Cache
108                    {
109                        // 1. Salva no Cache (para não buscar de novo por 7 dias)
110                        if let Ok(cache_conn) = state.metadata_db.lock() {
111                            let cache_key = format!("reviews_{}", app_id);
112                            if let Ok(json) = serde_json::to_string(&summary) {
113                                let _ = cache::save_cached_api_data(
114                                    &cache_conn,
115                                    "steam",
116                                    &cache_key,
117                                    &json,
118                                );
119                            }
120                        }
121                    }
122
123                    {
124                        // 2. Salva na Tabela de Jogos (Library)
125                        // Calcula a porcentagem de avaliações positivas
126                        let total = summary.total_reviews;
127                        let percent_positive = if total > 0 {
128                            (summary.total_positive as f64 / total as f64 * 100.0) as i32
129                        } else {
130                            0
131                        };
132
133                        if let Ok(conn) = state.library_db.lock() {
134                            let _ = conn.execute(
135                                "UPDATE games SET user_rating = ?1 WHERE platform = 'Steam' AND platform_id = ?2",
136                                params![percent_positive, app_id_str],
137                            );
138                        }
139                    }
140                    updated_count += 1;
141                }
142                Ok(None) => { /* Jogo não tem reviews ou erro 404, ignora */ }
143                Err(e) => {
144                    // E. Erro de API/Conexão? IGNORA. Mantém o dado velho.
145                    warn!("Falha ao buscar review {}: {}", app_id, e);
146                }
147            }
148            // Rate limit suave
149            sleep(Duration::from_millis(200)).await;
150        }
151    }
152
153    if updated_count > 0 {
154        info!("{} avaliações atualizadas", updated_count);
155    }
156
157    Ok(())
158}
159
160/// Atualiza preços da Wishlist se o cache expirou
161async fn refresh_wishlist_prices_background(
162    app: &AppHandle,
163    state: &State<'_, AppState>,
164) -> Result<(), String> {
165    // A. Ler Wishlist com itad_id
166    let wishlist_items: Vec<(String, String, Option<String>)> = {
167        let conn = state.library_db.lock().map_err(|_| "Falha DB")?;
168
169        conn.prepare("SELECT id, name, itad_id FROM wishlist")
170            .and_then(|mut stmt| {
171                stmt.query_map([], |row| {
172                    Ok((
173                        row.get::<_, String>(0)?,
174                        row.get::<_, String>(1)?,
175                        row.get::<_, Option<String>>(2)?,
176                    ))
177                })
178                .and_then(|mapped| mapped.collect::<Result<Vec<_>, _>>())
179            })
180            .map_err(|e| e.to_string())?
181    };
182
183    if wishlist_items.is_empty() {
184        return Ok(());
185    }
186
187    // B. Coleta IDs da ITAD que precisam atualizar
188    let mut itad_ids_to_fetch = Vec::new();
189    let mut game_map = std::collections::HashMap::new();
190
191    for (local_id, name, itad_id_opt) in wishlist_items {
192        // Verifica se tem ITAD ID
193        let itad_id = match itad_id_opt {
194            Some(id) if !id.is_empty() => id,
195            _ => {
196                // Se não tem ITAD ID, tenta buscar
197                match itad::find_game_id(&name).await {
198                    Ok(found_id) => {
199                        // Salva no banco para cachear
200                        let conn = state.library_db.lock().unwrap();
201                        let _ = conn.execute(
202                            "UPDATE wishlist SET itad_id = ?1 WHERE id = ?2",
203                            params![&found_id, &local_id],
204                        );
205                        found_id
206                    }
207                    Err(_) => {
208                        continue; // Pula se não encontrou
209                    }
210                }
211            }
212        };
213
214        // Verifica Cache
215        let should_update = {
216            let cache_conn = state.metadata_db.lock().unwrap();
217            let cache_key = format!("price_{}", itad_id);
218            // Se não existe em cache ou expirou, precisa atualizar
219            cache::get_cached_api_data(&cache_conn, "itad", &cache_key).is_none()
220        };
221
222        if should_update {
223            itad_ids_to_fetch.push(itad_id.clone());
224            game_map.insert(itad_id, (local_id, name));
225        }
226    }
227
228    if itad_ids_to_fetch.is_empty() {
229        return Ok(());
230    }
231
232    // C. Busca preços em lote da ITAD
233    let overviews = match itad::get_prices(itad_ids_to_fetch).await {
234        Ok(data) => data,
235        Err(e) => {
236            error!("Erro ao buscar preços da ITAD: {}", e);
237            return Err(e);
238        }
239    };
240
241    let mut updated_count = 0;
242
243    // D. Atualiza banco e cache
244    for game_data in overviews {
245        if let Some((local_id, _game_name)) = game_map.get(&game_data.id) {
246            // Salva no cache como um JSON simplificado
247            {
248                if let Ok(cache_conn) = state.metadata_db.lock() {
249                    let cache_key = format!("price_{}", game_data.id);
250
251                    // Cria um JSON manual com os dados relevantes
252                    let cache_data = serde_json::json!({
253                        "id": game_data.id,
254                        "current_price": game_data.current.as_ref().map(|d| d.price),
255                        "currency": game_data.current.as_ref().map(|d| &d.currency),
256                        "lowest_price": game_data.lowest.as_ref().map(|d| d.price),
257                    });
258
259                    let json = cache_data.to_string();
260                    let _ = cache::save_cached_api_data(&cache_conn, "itad", &cache_key, &json);
261                }
262            }
263
264            // Atualiza preços no banco de dados
265            if let Some(deal) = game_data.current {
266                let lowest = game_data.lowest.map(|l| l.price).unwrap_or(deal.price);
267
268                let cut = deal.cut.unwrap_or(0) as f64;
269                let normal_price = if cut > 0.0 {
270                    deal.price / (1.0 - (cut / 100.0))
271                } else {
272                    deal.price
273                };
274
275                if let Ok(conn) = state.library_db.lock() {
276                    match conn.execute(
277                        "UPDATE wishlist SET
278                            current_price = ?1,
279                            currency = ?2,
280                            lowest_price = ?3,
281                            store_platform = ?4,
282                            store_url = ?5,
283                            on_sale = ?6,
284                            normal_price = ?7,
285                            voucher = ?8
286                         WHERE id = ?9",
287                        params![
288                            deal.price,
289                            deal.currency,
290                            lowest,
291                            deal.shop.name,
292                            deal.url,
293                            deal.cut > Some(0),
294                            normal_price,
295                            deal.voucher,
296                            local_id
297                        ],
298                    ) {
299                        Ok(_) => updated_count += 1,
300                        Err(e) => error!("Erro ao atualizar preço: {}", e),
301                    }
302                }
303            }
304        }
305    }
306
307    if updated_count > 0 {
308        info!("{} preços atualizados", updated_count);
309        let _ = app.emit("wishlist_prices_updated", ());
310    }
311
312    Ok(())
313}